SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
10.2 KB · 173 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { ExternalLink } from 'lucide-react';5import { GraphLink } from '@/components/graph/graph-link';6import { PageHeader, Section, KV, Note } from '@/components/ui/section';7import { Badge, ClaimBadge } from '@/components/ui/badge';8import { EmptyState } from '@/components/ui/empty-state';9import { Freshness } from '@/components/ui/freshness';10import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';11import { JsonView } from '@/components/ui/json-view';12import { Pager } from '@/components/ui/pager';13import { EvidenceTable } from '@/components/data/evidence-table';14import { getVariantBySlug, variantAliases, clinicalSignificanceFor } from '@/lib/queries/genomics';15import { evidenceForVariant, evidenceForVariantCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';16import { loadProvenance, toInfo } from '@/lib/queries/provenance';17import { fmtInt, humanize } from '@/lib/format';18import { pageInfo } from '@/lib/pagination';19import { int, type SP } from '@/lib/search-params';2021export const revalidate = 3600;2223export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {24  const v = await getVariantBySlug((await params).slug);25  return v ? { title: `${v.gene_symbol ? `${v.gene_symbol} ` : ''}${v.name} — variant`, description: `Curated evidence by cancer and ClinVar interpretations for ${v.gene_symbol ?? ''} ${v.name}.`, alternates: { canonical: `/variant/${v.slug}` } } : { title: 'Variant' };26}2728const CLINVAR = { sourceSlug: 'clinvar', sourceName: 'ClinVar' };2930export default async function VariantPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {31  const { slug } = await params;32  const v = await getVariantBySlug(slug);33  if (!v) notFound();34  const sp = await searchParams;35  const evTotal = await evidenceForVariantCount(v.id);36  const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal);37  const [aliases, evidence, clinsig] = await Promise.all([variantAliases(v.id), evTotal ? evidenceForVariant(v.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), clinicalSignificanceFor(v.id)]);38  const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...clinsig.map((c) => c.provenance_id)]);39  // Evidence grouped by cancer (§50) — the query orders by cancer, so groups are contiguous within a page.40  const contexts = new Set(evidence.map((e) => e.cancer_id ?? `unmapped:${e.disease_name ?? 'unknown'}`)).size;41  const hrefFor = (p: number) => `/variant/${v.slug}${p > 1 ? `?evPage=${p}` : ''}#evidence`;42  const clinProv = clinsig[0] ? (toInfo(prov.get(clinsig[0].provenance_id)) ?? CLINVAR) : CLINVAR;4344  return (45    <article>46      <PageHeader kicker={`Variant${v.variant_type ? ` · ${humanize(v.variant_type)}` : ''}`} title={<>{v.gene_symbol ? <Link href={`/gene/${v.gene_symbol}`} className="ci-mono font-sans text-ink no-underline hover:text-accent">{v.gene_symbol}</Link> : null} {v.name}</>}>47        <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">48          <span className="ci-mono text-ink-3">{v.id}</span>49          <GraphLink type="variant" entityRef={v.slug} />50          {v.hgvs_p ? <span className="ci-mono">{v.hgvs_p}</span> : null}51          {v.hgvs_c ? <span className="ci-mono">{v.hgvs_c}</span> : null}52          {v.clinvar_variation_id ? (53            <a className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/clinvar/variation/${v.clinvar_variation_id}/`} target="_blank" rel="noopener noreferrer">54              ClinVar {v.clinvar_variation_id} <ExternalLink className="h-3 w-3" aria-hidden />55            </a>56          ) : null}57          {v.civic_variant_id ? (58            <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/variants/${v.civic_variant_id}/summary`} target="_blank" rel="noopener noreferrer">59              CIViC {v.civic_variant_id} <ExternalLink className="h-3 w-3" aria-hidden />60            </a>61          ) : null}62          {v.dbsnp_ids.map((r) => (63            <a key={r} className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/snp/${r}`} target="_blank" rel="noopener noreferrer">64              {r} <ExternalLink className="h-3 w-3" aria-hidden />65            </a>66          ))}67        </p>68      </PageHeader>6970      <div className="grid gap-8 lg:grid-cols-[1fr_320px]">71        <div className="space-y-8">72          <Section id="evidence" kicker="Curated evidence" title={`Evidence by cancer (${fmtInt(evTotal)} items)`} description={`Grouped by cancer context first, then therapy. The same variant can be sensitizing in one cancer and irrelevant in another — contexts are never merged. ${EVIDENCE_PAGE_SIZE} items per page; a cancer group may continue on the next page.`}>73            {evidence.length ? (74              <>75                <EvidenceTable76                  items={evidence}77                  prov={prov}78                  groupBy="cancer"79                  summary={80                    <>81                      Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items across {contexts} cancer context{contexts === 1 ? '' : 's'} on this page82                    </>83                  }84                />85                <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={hrefFor} label="Evidence pages" noun="evidence items" />86              </>87            ) : (88              <EmptyState compact>No curated evidence item references this variant yet.</EmptyState>89            )}90          </Section>9192          <Section id="clinvar" kicker="ClinVar" title={`Clinical significance (${fmtInt(clinsig.length)})`} description="ClinVar interpretations are shown as structured records — significance, review status, star rating, conditions — never flattened into one word.">93            {clinsig.length ? (94              <>95                <TableProvenance p={clinProv} claim={<ClaimBadge kind="curated" />}>96                  {clinsig.length} interpretation{clinsig.length === 1 ? '' : 's'} · review status and star rating as assigned by ClinVar.97                </TableProvenance>98                <div className="ci-table-wrap">99                  <table className="ci-table">100                    <thead>101                      <tr>102                        <th scope="col">Variation</th>103                        <th scope="col">Clinical significance</th>104                        <th scope="col">Review status</th>105                        <th scope="col" className="num">106                          Stars107                        </th>108                        <th scope="col">Conditions</th>109                        <th scope="col">Origin</th>110                        <th scope="col" className="num">111                          Submitters112                        </th>113                        <th scope="col">Last evaluated</th>114                        <th scope="col">Source</th>115                      </tr>116                    </thead>117                    <tbody>118                      {clinsig.map((c) => (119                        <tr key={c.id}>120                          <td className="ci-mono">{c.clinvar_variation_id}</td>121                          <td className="font-medium">{c.clinical_significance}</td>122                          <td className="text-[12.5px]">{c.review_status ?? '—'}</td>123                          <td className="num">{c.star_rating ?? '—'}</td>124                          <td className="max-w-[300px] text-[12.5px]">{c.conditions.join('; ') || '—'}</td>125                          <td className="text-[12.5px]">{c.origin_simple ?? '—'}</td>126                          <td className="num">{c.number_submitters ?? '—'}</td>127                          <td className="whitespace-nowrap text-[12.5px]">{c.last_evaluated ?? '—'}</td>128                          <td>129                            <SourceBadge compact title={null} p={toInfo(prov.get(c.provenance_id)) ?? CLINVAR} />130                          </td>131                        </tr>132                      ))}133                    </tbody>134                  </table>135                </div>136              </>137            ) : (138              <EmptyState compact>No ClinVar interpretation attached to this variant.</EmptyState>139            )}140          </Section>141        </div>142143        <aside className="space-y-8">144          <Section id="coordinates" kicker="Nomenclature" title="Record" level={3}>145            <KV146              items={[147                { k: 'Gene', v: v.gene_symbol ? <Link className="ci-mono ci-link" href={`/gene/${v.gene_symbol}`}>{v.gene_symbol}</Link> : null },148                { k: 'Type', v: v.variant_type ? humanize(v.variant_type) : null },149                { k: 'HGVS g.', v: v.hgvs_g ? <span className="ci-mono break-all">{v.hgvs_g}</span> : null },150                { k: 'HGVS c.', v: v.hgvs_c ? <span className="ci-mono break-all">{v.hgvs_c}</span> : null },151                { k: 'HGVS p.', v: v.hgvs_p ? <span className="ci-mono break-all">{v.hgvs_p}</span> : null },152                { k: 'Assembly', v: v.assembly ? <Badge mono>{v.assembly}</Badge> : null },153                { k: 'Position', v: v.chromosome && v.start ? <span className="ci-mono">chr{v.chromosome}:{v.start}{v.end && v.end !== v.start ? `-${v.end}` : ''}</span> : null },154                { k: 'Ref / Alt', v: v.reference_bases || v.alternate_bases ? <span className="ci-mono">{v.reference_bases ?? '?'} → {v.alternate_bases ?? '?'}</span> : null },155                { k: 'Fusion partners', v: v.fusion_partners.length ? v.fusion_partners.join(' :: ') : null },156                { k: 'Aliases', v: aliases.length ? aliases.join(', ') : null },157              ]}158            />159            {v.coordinates.length ? (160              <details className="mt-2 text-[12.5px]">161                <summary className="ci-link">Per-assembly coordinates</summary>162                <JsonView data={v.coordinates} />163              </details>164            ) : null}165            <Freshness dataUpdatedAt={v.updated_at} />166          </Section>167          <Note tone="warn">Coordinates always carry their assembly; do not compare positions across GRCh37 and GRCh38. Evidence is not treatment guidance.</Note>168        </aside>169      </div>170    </article>171  );172}173